Electron UI+交互初实现
概述
本节设计并实现类 utools/Alfred 的桌面端快速启动工具。核心交互:用户通过全局快捷键呼出输入框,输入内容后展示快速补全列表,选中操作后执行对应的 Node.js CLI 工具。涉及全局快捷键注册、窗口显示/隐藏、输入框自动聚焦、ESC 退出等交互细节。
功能架构
交互流程
用户按下快捷键 → 弹出输入框窗口 → 自动聚焦输入框
→ 用户输入关键词 → 展示匹配的操作列表
→ 选中操作 → 执行 CLI 命令
→ 用户按 ESC / 点击空白区域 → 隐藏窗口
→ 点击右侧按钮 → 打开插件市场详情页
text
技术模块划分
| 模块 | 技术点 | 说明 |
|---|---|---|
| 全局快捷键 | globalShortcut | 注册系统级快捷键,应用未聚焦时也能触发 |
| 窗口管理 | BrowserWindow | 窗口显示/隐藏/居中定位 |
| 输入交互 | 渲染进程 Vue 组件 | 输入框、补全列表、键盘导航 |
| 插件机制 | Node.js 动态加载 | 按需加载 CLI 工具模块 |
| 插件市场 | 独立页面 | 浏览、安装、管理插件 |
核心实现
主进程:全局快捷键与窗口管理
// main/launcher.ts
import { app, BrowserWindow, globalShortcut, screen } from 'electron'
import path from 'node:path'
let launcherWindow: BrowserWindow | null = null
const SHORTCUT = 'CommandOrControl+Shift+Space'
export function createLauncherWindow(): void {
const { width: screenWidth } = screen.getPrimaryDisplay().workAreaSize
launcherWindow = new BrowserWindow({
width: 600,
height: 400,
x: Math.round((screenWidth - 600) / 2),
y: 100,
frame: false, // 无边框
transparent: true, // 透明背景
resizable: false,
skipTaskbar: true, // 不显示在任务栏
alwaysOnTop: true, // 始终置顶
show: false, // 初始隐藏
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false
}
})
launcherWindow.loadFile(path.join(__dirname, '../renderer/launcher.html'))
// 窗口失焦时隐藏
launcherWindow.on('blur', () => {
if (launcherWindow?.isVisible()) {
launcherWindow.hide()
}
})
}
function toggleLauncher(): void {
if (!launcherWindow) return
if (launcherWindow.isVisible()) {
launcherWindow.hide()
} else {
launcherWindow.show()
// 通知渲染进程聚焦输入框
launcherWindow.webContents.send('launcher:focus-input')
}
}
export function registerGlobalShortcut(): void {
const success = globalShortcut.register(SHORTCUT, toggleLauncher)
if (!success) {
console.error(`快捷键 ${SHORTCUT} 注册失败(可能被其他应用占用)`)
}
}
export function unregisterGlobalShortcut(): void {
globalShortcut.unregister(SHORTCUT)
}
// 应用退出时清理
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
typescript
渲染进程:输入框与补全列表组件
<!-- renderer/components/LauncherInput.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Command {
id: string
name: string
description: string
icon: string
execute: () => void
}
const inputText = ref('')
const activeIndex = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
// 模拟命令列表(实际从插件系统加载)
const commands = ref<Command[]>([
{ id: '1', name: '翻译', description: '翻译选中文本', icon: '🌐', execute: () => {} },
{ id: '2', name: '计算器', description: '快速计算数学表达式', icon: '🔢', execute: () => {} },
{ id: '3', name: '颜色取色', description: '屏幕取色工具', icon: '🎨', execute: () => {} }
])
const filteredCommands = computed(() => {
if (!inputText.value) return commands.value
const keyword = inputText.value.toLowerCase()
return commands.value.filter(
cmd => cmd.name.toLowerCase().includes(keyword) ||
cmd.description.toLowerCase().includes(keyword)
)
})
function handleKeyDown(e: KeyboardEvent): void {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % filteredCommands.value.length
break
case 'ArrowUp':
e.preventDefault()
activeIndex.value =
(activeIndex.value - 1 + filteredCommands.value.length) % filteredCommands.value.length
break
case 'Enter':
if (filteredCommands.value[activeIndex.value]) {
filteredCommands.value[activeIndex.value].execute()
}
break
case 'Escape':
window.electronAPI.hideLauncher()
break
}
}
// 监听主进程的聚焦事件
onMounted(() => {
window.electronAPI.onFocusInput(() => {
inputText.value = ''
activeIndex.value = 0
inputRef.value?.focus()
})
})
</script>
<template>
<div class="launcher-container">
<div class="input-wrapper">
<input
ref="inputRef"
v-model="inputText"
class="search-input"
placeholder="输入命令或搜索..."
@keydown="handleKeyDown"
/>
<button class="market-btn" title="插件市场">⊞</button>
</div>
<ul class="command-list">
<li
v-for="(cmd, index) in filteredCommands"
:key="cmd.id"
class="command-item"
:class="{ active: index === activeIndex }"
@click="cmd.execute()"
@mouseenter="activeIndex = index"
>
<span class="command-icon">{{ cmd.icon }}</span>
<div class="command-info">
<span class="command-name">{{ cmd.name }}</span>
<span class="command-desc">{{ cmd.description }}</span>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.launcher-container {
background: var(--el-bg-color);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.input-wrapper {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color);
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
background: transparent;
color: var(--el-text-color-primary);
}
.market-btn {
padding: 4px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 18px;
color: var(--el-text-color-secondary);
}
.command-list {
list-style: none;
margin: 0;
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
}
.command-item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
}
.command-item.active {
background: var(--el-color-primary-light-9);
}
.command-icon {
font-size: 20px;
margin-right: 12px;
}
.command-info {
display: flex;
flex-direction: column;
}
.command-name {
font-size: 14px;
font-weight: 500;
}
.command-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
vue
Preload 桥接
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
hideLauncher: () => ipcRenderer.send('launcher:hide'),
onFocusInput: (callback: () => void) =>
ipcRenderer.on('launcher:focus-input', callback)
})
typescript
实践要点
- 窗口设置
frame: false+transparent: true实现无边框悬浮窗效果 alwaysOnTop: true确保输入面板始终在最上层- 监听窗口
blur事件自动隐藏,用户点击其他区域时收起面板 - 全局快捷键必须在
app.on('will-quit')中调用unregisterAll()清理注册 - 渲染进程通过
ipcRenderer.on('launcher:focus-input')接收主进程聚焦指令 - 键盘导航支持
ArrowUp/Down选择、Enter确认、Escape关闭 - macOS 存在已知 bug:
globalShortcut在非 QWERTY 键盘布局下可能失效
↑